come scrivere del codice corretto e tornare a godersi la vita
Un codice corretto è un codice senza bug e senza errori
Un codice ha un bug quando si comporta diversamente da quanto scritto nella sua documentazione.
Nella documentazione va inclusa la documentazione esterna (manuale), quella interna (docstrings) e quella implicita (nome delle funzione ed argomenti).
Sono in generale considerati bug anche i commenti ed i nomi delle variabili che non corrispondono a cosa sta facendo il codice.
la funzione più buggata della storia
SPECIFICA: scrivi una funzione che integri una parabola fra due numeri
def moltiplicazione(nome, cognome):
"""questa funzione divide due numeri"""
# eseguo la sottrazione
esponente = nome[cognome]
return esponente
Il codice è corretto ed esegue bene, ma usarla in un codice reale sarebbe un suicidio!
un codice è errato quando si comporta diversamente da quello che la logica per cui è stato scritto prescrive
Ad esempio una funzione di ordinamento che non ordina, oppure in alcuni casi particolari non ordina bene.
prendete spunto dalla documentazione di numpy. non si può chiedere molto altro.
import numpy
print("\n".join(numpy.linalg.eig.__doc__.splitlines()[:20]))
Compute the eigenvalues and right eigenvectors of a square array. Parameters ---------- a : (..., M, M) array Matrices for which the eigenvalues and right eigenvectors will be computed Returns ------- w : (..., M) array The eigenvalues, each repeated according to its multiplicity. The eigenvalues are not necessarily ordered. The resulting array will be of complex type, unless the imaginary part is zero in which case it will be cast to a real type. When `a` is real the resulting eigenvalues will be real (0 imaginary part) or occur in conjugate pairs v : (..., M, M) array
print("\n".join(numpy.linalg.eig.__doc__.splitlines()[20:39]))
The normalized (unit "length") eigenvectors, such that the column ``v[:,i]`` is the eigenvector corresponding to the eigenvalue ``w[i]``. Raises ------ LinAlgError If the eigenvalue computation does not converge. See Also -------- eigvals : eigenvalues of a non-symmetric array. eigh : eigenvalues and eigenvectors of a symmetric or Hermitian (conjugate symmetric) array. eigvalsh : eigenvalues of a symmetric or Hermitian (conjugate symmetric) array.
print("\n".join(numpy.linalg.eig.__doc__.splitlines()[39:60]))
Notes ----- .. versionadded:: 1.8.0 Broadcasting rules apply, see the `numpy.linalg` documentation for details. This is implemented using the _geev LAPACK routines which compute the eigenvalues and eigenvectors of general square arrays. The number `w` is an eigenvalue of `a` if there exists a vector `v` such that ``dot(a,v) = w * v``. Thus, the arrays `a`, `w`, and `v` satisfy the equations ``dot(a[:,:], v[:,i]) = w[i] * v[:,i]`` for :math:`i \in \{0,...,M-1\}`. The array `v` of eigenvectors may not be of maximum rank, that is, some of the columns may be linearly dependent, although round-off error may obscure that fact. If the eigenvalues are all different, then theoretically the eigenvectors are linearly independent. Likewise, the (complex-valued) matrix of eigenvectors `v` is unitary if the matrix `a` is normal, i.e.,
print("\n".join(numpy.linalg.eig.__doc__.splitlines()[60:80]))
if ``dot(a, a.H) = dot(a.H, a)``, where `a.H` denotes the conjugate transpose of `a`. Finally, it is emphasized that `v` consists of the *right* (as in right-hand side) eigenvectors of `a`. A vector `y` satisfying ``dot(y.T, a) = z * y.T`` for some number `z` is called a *left* eigenvector of `a`, and, in general, the left and right eigenvectors of a matrix are not necessarily the (perhaps conjugate) transposes of each other. References ---------- G. Strang, *Linear Algebra and Its Applications*, 2nd Ed., Orlando, FL, Academic Press, Inc., 1980, Various pp. Examples -------- >>> from numpy import linalg as LA (Almost) trivial example with real e-values and e-vectors.
print("\n".join(numpy.linalg.eig.__doc__.splitlines()[80:100]))
>>> w, v = LA.eig(np.diag((1, 2, 3))) >>> w; v array([ 1., 2., 3.]) array([[ 1., 0., 0.], [ 0., 1., 0.], [ 0., 0., 1.]]) Real matrix possessing complex e-values and e-vectors; note that the e-values are complex conjugates of each other. >>> w, v = LA.eig(np.array([[1, -1], [1, 1]])) >>> w; v array([ 1. + 1.j, 1. - 1.j]) array([[ 0.70710678+0.j , 0.70710678+0.j ], [ 0.00000000-0.70710678j, 0.00000000+0.70710678j]]) Complex-valued matrix with real e-values (but complex-valued e-vectors); note that a.conj().T = a, i.e., a is Hermitian.
print("\n".join(numpy.linalg.eig.__doc__.splitlines()[100:120])) #fine
>>> a = np.array([[1, 1j], [-1j, 1]]) >>> w, v = LA.eig(a) >>> w; v array([ 2.00000000e+00+0.j, 5.98651912e-36+0.j]) # i.e., {2, 0} array([[ 0.00000000+0.70710678j, 0.70710678+0.j ], [ 0.70710678+0.j , 0.00000000+0.70710678j]]) Be careful about round-off error! >>> a = np.array([[1 + 1e-9, 0], [0, 1 - 1e-9]]) >>> # Theor. e-values are 1 +/- 1e-9 >>> w, v = LA.eig(a) >>> w; v array([ 1., 1.]) array([[ 1., 0.], [ 0., 1.]])
scrivete funzioni pure.
Una funzione è chiamata pura se, a parità di input, ritorna lo stesso output.
Questo vi permette di essere sicuri che una volta verificata la correttezza di una funzione, questo risultato non cambi.
Questo è anche uno dei motivi per cui le variabili globali sono considerate cattiva pratica
Riprendiamo il codice della seconda lezione sugli automi cellulari.
Vogliamo testare la nostra funzione per verificare che si comporti in modo corretto.
Che test possiamo pensare di fare?
test di avanzamento: le modifiche che ho introdotto nel mio codice fanno quello che penso
test di regressione: le modifiche che ho introdotto nel mio codice non cambiano come funziona il resto del codice
test positivi: il mio codice mi da il riultato che mi aspetto
test negativi: il mio codice fallisce quando non rispetto le richieste
Quando scriviamo una funzione, di solito testiamo se funziona senza problemi.
Questo tipo di test è ovviamente necessario, ma ne incontriamo subito i limiti.
def inc(x):
return x + 1
assert inc(3)==4
assert inc(5)==4
assert inc(6)==4
--------------------------------------------------------------------------- AssertionError Traceback (most recent call last) <ipython-input-33-a12389ee159e> in <module>() 3 4 assert inc(3)==4 ----> 5 assert inc(5)==4 6 assert inc(6)==4 AssertionError:
mi salvo in uno script i test che ho effettuato con degli assert.
Ogni volta che faccio una modifica al mio codice lancio i test per vedere che tutto funzioni.
Se osservo un bug, inserisco un nuovo test che mi garantisca che quel bug non si ripresenti.
in generale voglio almeno un esempio che mi mostri il caso tipico d'uso, più un esempio per ogni caso limite.
Immaginate di scrivere una funzione che vi metta in ordine una lista. Volete testare:
[1, 3, 2]
, che dia il risultato [1, 2, 3]
[1, 2, 3]
, dia come risultato la stessa lista [1, 2, 3]
e così via, ripetendo per diverse liste.
un'ottima libreria per lo unit testing è pytest.
Pytest è un comando da shell che ricerca le funzioni chiamate test_qualcosa e le esegue tutte, riportandoci i risultati
%%file test_prova.py
def inc(x):
return x + 1
def test_answer_1():
assert inc(3) == 5
def test_answer_2():
assert inc(7) == 7
Overwriting test_prova.py
!pytest test_prova.py
============================= test session starts ============================== platform linux -- Python 3.6.0, pytest-3.0.6, py-1.4.32, pluggy-0.4.0 rootdir: /home/enrico, inifile: plugins: xonsh-0.5.6, hypothesis-3.6.1 collected 2 items test_prova.py FF =================================== FAILURES =================================== ________________________________ test_answer_1 _________________________________ def test_answer_1(): > assert inc(3) == 5 E assert 4 == 5 E + where 4 = inc(3) test_prova.py:5: AssertionError ________________________________ test_answer_2 _________________________________ def test_answer_2(): > assert inc(7) == 7 E assert 8 == 7 E + where 8 = inc(7) test_prova.py:8: AssertionError =========================== 2 failed in 0.03 seconds ===========================
Potrei eseguire il codice di test anche manualmente, ma questo avrebbe diversi svantaggi:
Pytest può inoltre controllare anche se ci aspettiamo un'eccezione, cosa molto più scomoda con il codice normale
import pytest
def test_zero_division():
with pytest.raises(ZeroDivisionError):
1 / 0
Pytest automatizza già notevolmente la nostra procedura di test, ma dobbiamo scrivere ancora a mano un gran numero di test diversi e simili fra di loro.
Dobbiamo ancora trovare un modo per migliorare: l'ideale sarebbe che il computer generasse i test al posto nostro!
Questo non è possibile in senso letterale, ma ci possiamo arrivare abbastanza vicini.
Vado a generalizzare i test che ho scritto in modo aneddotico
Nello unit test:
Nel property test:
la libreria che uso genererà in maniera casuale i dati in input secondo le regole che ho specificato, li lancerà contro la funzione e cercherà di romperla in tutti i modi.
Se riesce a violare una proprietà, semplifica l'esempio fino a trovare l'esempio più piccolo possibile che ancora vìola quella proprietà, e ce lo restituisce.
Il property based testing non rimpiazza lo unit testing.
Lo estende e lo rende più potente, mentre allo stesso tempo riduce la quantità di codice triviale che dovete scrivere.
Ovviamente per usarlo dovrete pensare di più, ma non sareste qui se aveste paura di pensare.
La libreria che useremo per i property test si chiama hypothesis.
Hypotesis si appoggia a librerie come pytest per il testing, ma genera in modo automatico i test tramite le strategie, che definiscono come dei dati casuali debbano essere passati alla libreria di test.
%%file test_prova.py
from hypothesis import given
import hypothesis.strategies as st
def inc(x):
if x==5:
return 0
return x + 1
def dec(x):
return x - 1
@given(value=st.integers())
def test_answer_1(value):
print(value)
assert dec(inc(value)) == value
@given(value=st.integers())
def test_answer_2(value):
assert dec(inc(value)) == inc(dec(value))
Overwriting test_prova.py
!pytest test_prova.py
============================= test session starts ============================== platform linux -- Python 3.6.0, pytest-3.0.6, py-1.4.32, pluggy-0.4.0 rootdir: /home/enrico, inifile: plugins: xonsh-0.5.6, hypothesis-3.6.1 collected 2 items test_prova.py .F =================================== FAILURES =================================== ________________________________ test_answer_2 _________________________________ @given(value=st.integers()) > def test_answer_2(value): test_prova.py:18: _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ /usr/local/lib/python3.6/site-packages/hypothesis/core.py:524: in wrapped_test print_example=True, is_final=True /usr/local/lib/python3.6/site-packages/hypothesis/executors.py:58: in default_new_style_executor return function(data) /usr/local/lib/python3.6/site-packages/hypothesis/core.py:111: in run return test(*args, **kwargs) _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ value = 5 @given(value=st.integers()) def test_answer_2(value): > assert dec(inc(value)) == inc(dec(value)) E assert -1 == 5 E + where -1 = dec(0) E + where 0 = inc(5) E + and 5 = inc(4) E + where 4 = dec(5) test_prova.py:19: AssertionError ---------------------------------- Hypothesis ---------------------------------- Falsifying example: test_answer_2(value=5) ====================== 1 failed, 1 passed in 0.15 seconds ======================
Per arrivare a scrivere i test di proprietà non dobbiamo necessariamente partire da zero, ma possiamo costruirli sulla base degli unit test, usando la strategia just
%%file test_prova.py
def inc(x):
return x + 1
def test_answer_1a():
assert inc(3) == 4
from hypothesis import given
import hypothesis.strategies as st
@given(x=st.just(3))
def test_answer_1b(x):
assert inc(x) == x+1
@given(x=st.floats())
def test_answer_1c(x):
assert inc(x) == x+1
Overwriting test_prova.py
!pytest test_prova.py
============================= test session starts ============================== platform linux -- Python 3.6.0, pytest-3.0.6, py-1.4.32, pluggy-0.4.0 rootdir: /home/enrico, inifile: plugins: xonsh-0.5.6, hypothesis-3.6.1 collected 3 items test_prova.py ..F =================================== FAILURES =================================== ________________________________ test_answer_1c ________________________________ @given(x=st.floats()) > def test_answer_1c(x): test_prova.py:16: _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ /usr/local/lib/python3.6/site-packages/hypothesis/core.py:524: in wrapped_test print_example=True, is_final=True /usr/local/lib/python3.6/site-packages/hypothesis/executors.py:58: in default_new_style_executor return function(data) /usr/local/lib/python3.6/site-packages/hypothesis/core.py:111: in run return test(*args, **kwargs) _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ x = nan @given(x=st.floats()) def test_answer_1c(x): > assert inc(x) == x+1 E assert nan == (nan + 1) E + where nan = inc(nan) test_prova.py:17: AssertionError ---------------------------------- Hypothesis ---------------------------------- Falsifying example: test_answer_1c(x=nan) ====================== 1 failed, 2 passed in 0.62 seconds ======================
Come dicevamo, la matematica sul computer è difficile.
Se volessimo ignorare questo caso, potremmo usare la direttiva assume, che impone il rispetto di una condizione agli esempi forniti.
from math import isnan
from hypothesis import assume
@given(x=st.floats())
def test_answer_1c(x):
assume(not isnan(x))
assert inc(x) == x+1
gli esempi di proprietà sono basati su quelli presentati in questo sito
Nel test driven development, il codice ed i test sono scritti insieme, a partire dalle specifiche. Esistono molte varianti di questo concetto, ma l'idea di base è che non bisogna aspettare di scrivere tutto il codice per iniziare a scrivere i test. Nei casi più estremi si possono addirittura scrivere i test prima ancora del codice!
Nel design di architetture complicate, in cui il design è necessariamente top-down, è una pratica insostituibile.
Riprendiamo l'esempio di ieri, cercando di lavorare sulla base di test possibili.
Usiamo quindi l'approcci opposto rispetto a ieri, ovvero un top-down.
Nell'approccio top down svilupperemo il nostro codice fingendo che funzioni tutto bene, poi lo faremo diventare realtà:
def simulazione(nsteps):
stato_iniziale = genera_stato()
stati = [stato_iniziale]
for i in range(nsteps):
vecchio_stato = stati[-1]
nuovo_stato = evolvi(vecchio_stato)
stati.append(nuovo_stato)
return stati
notate come non abbia ancora definito in cosa consista la funzione genera_stato e la funzione evolvi.
Ora vado ad implementare degli stubs.
Quali sono le versioni più semplici che possono pensare per far eseguire il mio codice?
Partiamo dall'idea di lavorare su stringhe come ieri (non è obbligatorio, è solo una possibilità)
def genera_stato():
return "stringa"
def evolvi(stato):
return stato
simulazione(5)
['stringa', 'stringa', 'stringa', 'stringa', 'stringa', 'stringa']
Sembra banale, ma ora abbiamo un codice che fa qualcosa, e possiamo migliorarlo incrementalmente invece di cercare di ideare tutto insieme!
Questo approccio ci permette di dividere il problema in sottopassaggi più digeribili, ma richiede di fare qualche assunzione.
Iniziamo ore a scrivere i nostri test.
Partiamo dalla generazione del nostro stato.
Che proprietà vogliamo che abbia?
Ad esempio, potremmo richiedere che i valori possibili siano soltato '.'
ed '0'
%%file test_prova.py
def genera_stato():
return "stringa"
def evolvi(stato):
return stato
def simulazione(nsteps):
stato_iniziale = genera_stato()
stati = [stato_iniziale]
for i in range(nsteps):
vecchio_stato = stati[-1]
nuovo_stato = evolvi(vecchio_stato)
stati.append(nuovo_stato)
return stati
########################################################
def test_generazione():
stato = genera_stato()
assert set(stato) == {'.', '0'}
Writing test_prova.py
!pytest test_prova.py
============================= test session starts ============================== platform linux -- Python 3.6.0, pytest-3.0.6, py-1.4.32, pluggy-0.4.0 rootdir: /home/enrico/lavoro/DataProgrammingCourse, inifile: plugins: xonsh-0.5.7, hypothesis-3.6.1 collected 1 items test_prova.py F =================================== FAILURES =================================== _______________________________ test_generazione _______________________________ def test_generazione(): stato = genera_stato() > assert set(stato) == {'.', '0'} E assert {'a', 'g', 'i...'r', 's', ...} == {'.', '0'} E Extra items in the left set: E 'r' E 'i' E 'n' E 's' E 't' E 'a' E 'g' E Extra items in the right set: E '.' E '0' E Use -v to get the full diff test_prova.py:21: AssertionError =========================== 1 failed in 0.02 seconds ===========================
%%file test_prova.py
def genera_stato():
return "....00......"
def evolvi(stato):
return stato
def simulazione(nsteps):
stato_iniziale = genera_stato()
stati = [stato_iniziale]
for i in range(nsteps):
vecchio_stato = stati[-1]
nuovo_stato = evolvi(vecchio_stato)
stati.append(nuovo_stato)
return stati
########################################################
def test_generazione():
stato = genera_stato()
assert set(stato) == {'.', '0'}
Overwriting test_prova.py
!pytest test_prova.py
============================= test session starts ============================== platform linux -- Python 3.6.0, pytest-3.0.6, py-1.4.32, pluggy-0.4.0 rootdir: /home/enrico/lavoro/DataProgrammingCourse, inifile: plugins: xonsh-0.5.7, hypothesis-3.6.1 collected 1 items test_prova.py . =========================== 1 passed in 0.00 seconds ===========================
Il nostro test funziona!
Certo, non abbiamo una funzione che genera uno stato interessante, ma intanto genera uno stato valido!
I metodi TDD ci evitano anche di cadere nel trucco della sovraingegnerizzazione.
Non aggiungiamo nuove features al codice finché non ci servono!
La prossima richiesta che potremmo mettere è che non solo contenga solo '.'
ed '0'
, ma che ci sia soltanto uno '0'
.
%%file test_prova.py
def genera_stato():
return "....00......"
def evolvi(stato):
return stato
def simulazione(nsteps):
stato_iniziale = genera_stato()
stati = [stato_iniziale]
for i in range(nsteps):
vecchio_stato = stati[-1]
nuovo_stato = evolvi(vecchio_stato)
stati.append(nuovo_stato)
return stati
########################################################
def test_generazione():
stato = genera_stato()
assert set(stato) == {'.', '0'}
def test_generazione():
stato = genera_stato()
num_of_0 = sum(1 for i in stato if i=='0')
assert num_of_0 == 1
Overwriting test_prova.py
!pytest test_prova.py
============================= test session starts ============================== platform linux -- Python 3.6.0, pytest-3.0.6, py-1.4.32, pluggy-0.4.0 rootdir: /home/enrico/lavoro/DataProgrammingCourse, inifile: plugins: xonsh-0.5.7, hypothesis-3.6.1 collected 1 items test_prova.py F =================================== FAILURES =================================== _______________________________ test_generazione _______________________________ def test_generazione(): stato = genera_stato() num_of_0 = sum(1 for i in stato if i=='0') > assert num_of_0 == 1 E assert 2 == 1 test_prova.py:26: AssertionError =========================== 1 failed in 0.02 seconds ===========================
%%file test_prova.py
def genera_stato():
return ".....0......"
def evolvi(stato):
return stato
def simulazione(nsteps):
stato_iniziale = genera_stato()
stati = [stato_iniziale]
for i in range(nsteps):
vecchio_stato = stati[-1]
nuovo_stato = evolvi(vecchio_stato)
stati.append(nuovo_stato)
return stati
########################################################
def test_generazione():
stato = genera_stato()
assert set(stato) == {'.', '0'}
def test_generazione():
stato = genera_stato()
num_of_0 = sum(1 for i in stato if i=='0')
assert num_of_0 == 1
Overwriting test_prova.py
!pytest test_prova.py
============================= test session starts ============================== platform linux -- Python 3.6.0, pytest-3.0.6, py-1.4.32, pluggy-0.4.0 rootdir: /home/enrico/lavoro/DataProgrammingCourse, inifile: plugins: xonsh-0.5.7, hypothesis-3.6.1 collected 1 items test_prova.py . =========================== 1 passed in 0.01 seconds ===========================